문단 클릭 = 시작 위치 선택 · 재생은 우하단 버튼/P (재생 중 클릭 = 점프)

2장. RAG 한 줄 직접 만들기

출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅, 프리렉 2025) | 공식: www.langchain.com

코드는 분위기만 — embed_query·split_documents·| 같은 말은 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.

이 장은 0장에서 그림으로만 봤던 RAG 한 줄을, 이번엔 부품 하나하나 직접 손에 쥐고 만들어 보는 장이다.

문서를 숫자로 바꾸고, 바깥 파일을 읽어 들이고, 길면 자르고, 창고에 넣고, 비슷한 걸 꺼내, 답을 짓는다.

0장 용어와 척추가 머리에 있으면 충분하다. 막히면 0장으로 돌아가면 된다.


0. 이 장의 새 단어 (0장에 없는 것만)

0장 용어집에 없는 말은 딱 3개다.

나머지 어려운 말(임베딩·벡터·청크·검색기·코사인 유사도 등)은 전부 0장에 있다.


메타데이터(metadata)

한 문장 뜻 — 문서 본문 말고, 그 문서에 딸린 꼬리표 정보. 어디서 왔는지(출처)·몇 페이지인지·제목 같은 것.

일상비유 — 택배 상자에 붙은 송장. 상자 안 물건(본문)과 별개로, 보낸 곳·받는 곳·무게가 송장(메타데이터)에 적혀 있다.

한 줄 예 —

# 본문은 page_content, 꼬리표는 metadata
doc.page_content   # "2024년 주택시장 전망은..."  ← 알맹이
doc.metadata       # {'source': '보고서.pdf', 'page': 8}  ← 꼬리표

유사도 검색 / 상위 k개(similarity search / top-k)

한 문장 뜻 — 질문과 가장 비슷한 조각 몇 개만 골라 꺼내 오는 일. 몇 개 꺼낼지를 정하는 숫자가 k다.

일상비유 — 검색창에 단어를 치면 위에서부터 관련 글 3개만 보여 주는 것. 100만 개를 다 보지 않고 위 몇 개만 본다.

한 줄 예 —

# 질문과 비슷한 조각 상위 3개만 꺼내기
docs = store.similarity_search("환불 규정", k=3)

스트림릿(Streamlit)

한 문장 뜻 — 파이썬 코드만으로 웹 화면(채팅창·버튼)을 만들어 주는 도구. HTML·CSS를 몰라도 된다.

일상비유 — 조립 안 된 가구 대신, 이미 다 만들어진 책상을 사 오는 것. 채팅창·입력칸이 이미 부품으로 있어 가져다 쓰기만 하면 된다.

한 줄 예 —

import streamlit as st
st.title("내 챗봇")              # 화면에 제목 한 줄
user = st.chat_input("질문하세요")  # 채팅 입력칸 하나

(귀납 도입) 이런 적 있죠?

똑똑한 AI에게 "이 PDF 보고서 내용 알려줘"라고 했더니, 그 파일을 본 적도 없으면서 그럴듯하게 둘러댄다.

0장에서 봤다. AI는 자기가 배운 것 밖을 모른다. 우리 PDF는 배운 적이 없다.

그럼 어떻게 해야 AI가 우리 PDF를 보고 답하게 만들까?

순서는 이렇다.

먼저 PDF 글을 컴퓨터가 비교할 수 있게 숫자로 바꾼다.

그다음 PDF 파일을 읽어 들인다.

90,000자나 되는 긴 글은 한 번에 못 넣으니 조각으로 자른다.

조각들을 창고에 넣어 둔다.

질문이 오면 비슷한 조각만 꺼낸다.

그 조각을 AI에게 쥐여 주며 "이거 보고 답해"라고 한다.

# 이 장에서 만들 한 줄 (지금은 분위기만)
질문 → 비슷한 조각 검색 → 조각 + 질문을 AI에게 → 답

이 한 줄을, 이제 부품별로 하나씩 만들어 본다.


이 장에서 딱 6가지만

TL;DR

  1. 임베딩·코사인 유사도 — 글을 숫자로 바꾸고, 방향이 같으면 비슷하다고 잰다.
  2. 랭체인 임베딩 API — 오픈AI(유료·고품질)와 허깅페이스(무료·로컬) 두 길이 있다.
  3. 문서 로더 — 웹·PDF·CSV 같은 바깥 파일을 같은 모양으로 읽어 들인다.
  4. 텍스트 분할 — 길이로 자르거나(빠름), 의미로 자른다(똑똑·비쌈).
  5. 벡터 DB — 숫자 조각을 창고(Chroma·FAISS)에 넣고 비슷한 걸 빠르게 꺼낸다.
  6. RAG 챗봇 + 스트림릿 — 부품을 한 줄로 잇고, 화면을 입힌다.

학습 목표

  • RAG의 인덱싱 과정과 쿼리 과정을 구분한다.
  • 워드·문장·문서 임베딩을 구분하고 코사인 유사도가 무엇인지 설명한다.
  • 문서 로더·텍스트 분할기·벡터 DB가 각각 무슨 일을 하는지 설명한다.
  • 길이 분할과 의미 분할의 차이를 설명한다.

개념 1 — 임베딩과 코사인 유사도: 글을 숫자로, 비슷함은 방향으로

망가지는 장면

"과일 값이 비싸다"와 "시장 물가가 올랐어요"는 사람 눈엔 비슷하다.

그런데 컴퓨터한테 글자 그대로 비교시키면, 겹치는 글자가 없어 "전혀 다른 글"이라 한다.

의미가 비슷한 글을 영영 못 찾는다.

일상비유

사람마다 키·나이를 숫자 신상카드로 적어 두면, 이름이 달라도 카드 숫자가 비슷한 사람을 찾을 수 있다.

글도 숫자 카드(임베딩)로 바꾸면 의미가 비슷한 글을 찾을 수 있다.

비슷한 정도는 방향으로 잰다. 두 손가락이 같은 곳을 가리키면 비슷한 것이다.

비유 코드 위험
글 → 숫자 신상카드 embeddings.embed_query("저는 배가 고파요") 임베딩 모델이 나쁘면 카드가 엉터리
같은 방향이면 비슷(1) cos_sim(vec2, vec3) → 1.0 1에 가까울수록 비슷
직각(0)·정반대(-1) cos_sim(vec1, vec2) → 0.7 점수 낮으면 관련 없는 글

한 문장 정의 — 임베딩은 글을 숫자 묶음으로 바꾸는 일이고, 코사인 유사도는 두 묶음이 같은 방향을 보는 정도(-1~1)로 비슷함을 재는 방법이다.

단계별 예시

예시 1 (worked — 완성). 임베딩에는 세 종류가 있다. 단위만 다르다.

# 단어 하나를 숫자로 → 워드 임베딩
"랭체인"      → [-0.01, -0.02, 0.03, 0.12]
# 문장 하나를 숫자로 → 문장 임베딩
"안녕하세요"  → [-0.01, -0.05, -0.16, 0.19]
# 문서 통째로 숫자로 → 문서 임베딩
"랭체인은 ..." → [-0.05,  0.21, 0.04, 0.05]

예시 2 (worked — 완성). 코사인 유사도 점수는 이렇게 읽는다.

import numpy as np
from numpy import dot
from numpy.linalg import norm

# 분자 dot(A, B): 두 화살표가 같은 방향을 볼수록 커지는 값
# 분모 norm(A)*norm(B): 화살표 길이의 곱 — 이걸로 나눠 길이 영향을 지운다
# 그래서 결과에는 '방향이 얼마나 같은가'만 남는다 (-1 ~ 1)
def cos_sim(A, B):
    return dot(A, B) / (norm(A) * norm(B))

# 실제 임베딩 대신, 눈으로 검산할 수 있는 작은 장난감 벡터로 실험한다
vec2 = np.array([1, 0, 2, 1])
vec3 = np.array([2, 0, 4, 2])   # vec2를 딱 2배 한 것
cos_sim(vec2, vec3)  # → 1.0  (크기는 2배지만 방향이 같다 → 매우 비슷)

크기가 2배여도 유사도는 1.0이다.

코사인은 크기를 무시하고 방향만 본다는 증거다.

긴 글과 짧은 글이 길이 차이만으로 "다르다"고 나오는 걸 막아 준다.

예시 3 (부분완성 — 빈칸 채우기). 아래 빈칸에 들어갈 값을 골라 보자.

# 두 벡터가 정반대 방향이면 코사인 유사도는?
cos_sim([1, 0], [-1, 0])  # → ____
# 보기: 1.0 / 0.0 / -1.0

정답은 -1.0. 정반대 방향이니까.

예시 4 (독립적용 — 미니 시나리오). "과일 값이 비싸다"로 6개 문장 중 비슷한 걸 찾는다.

문장 6개를 모두 숫자 카드로 바꿔 둔다.

질문도 숫자 카드로 바꾼다.

질문 카드와 6개 카드의 코사인 유사도를 각각 잰다.

점수 높은 순으로 3개를 꺼낸다 → '전통 시장', '시장 물가', '주식 시장'.

글자는 안 겹치는데 의미로 찾아냈다.

참고 — 책 2장에서는 점수를 직접 잴 때 코사인 유사도를, 의미 기반 분할(아래 개념 4)에서는 코사인 거리(= 1 − 코사인 유사도)를 쓴다. 유사도는 클수록 비슷, 거리는 작을수록 비슷이다. 같은 걸 뒤집어 본 것뿐이다.


개념 2 — 랭체인 임베딩 API: 유료 길과 무료 길

망가지는 장면

숫자 카드를 만들려면 임베딩 모델이 필요하다.

그런데 모델이 한두 개가 아니다.

어떤 건 돈을 내고 쓰고, 어떤 건 공짜다. 어떤 건 영어를 잘하고, 어떤 건 한국어를 잘한다.

뭘 골라야 할지 막막하다.

일상비유

번역가를 고용하는 것과 같다.

비싼 전문 통역사(오픈AI)는 품질이 좋지만 부를 때마다 돈이 든다.

무료 자원봉사 번역가(허깅페이스 오픈 모델)는 공짜지만 내 컴퓨터에서 직접 돌려야 하고, 분야에 따라 실력 차가 있다.

비유 코드 위험
비싼 전문 통역사 OpenAIEmbeddings(model="text-embedding-3-small") API 호출마다 비용 발생
무료 자원봉사자 HuggingFaceEmbeddings(model_name="BAAI/bge-m3") 복잡한 맥락은 살짝 약함

한 문장 정의 — 랭체인 임베딩 API는 글을 숫자로 바꾸는 두 길을 제공한다. 유료지만 고품질인 LLM 제공사 모델과, 무료지만 로컬에서 돌리는 허깅페이스 오픈 모델이다.

단계별 예시

예시 1 (worked — 완성). 유료 길(오픈AI)로 문장 하나를 숫자로 바꾼다.

from langchain_openai import OpenAIEmbeddings

# 오픈AI API 키를 미리 컴퓨터에 등록해 둬야 한다 — 없으면 어느 계정에 요금을 매길지 몰라 에러가 난다
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 이 줄이 실제로 오픈AI 서버에 요청을 보낸다 — 부를 때마다 돈이 든다
vec = embeddings.embed_query("저는 배가 고파요")
len(vec)   # 1536  ← 숫자 1536개짜리 카드

예시 2 (worked — 완성). 무료 길(허깅페이스 bge-m3)은 모델 이름만 바꾸면 끝이다.

# 허깅페이스 연동은 전용 패키지를 씁니다: pip install langchain-huggingface
from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-m3")
# 한국어에 강하고, 내 컴퓨터에서 무료로 돈다
# 처음 실행할 땐 모델 파일을 내려받느라 시간이 걸린다 (그다음부턴 바로 뜬다)

부품 하나(모델)만 갈아 끼웠다.

나머지 코드는 그대로다.

이게 0장 척추 7번 "랭체인은 레고"의 실제 모습이다.

예시 3 (독립적용 — 미니 시나리오). 어떤 길을 고를까?

회사 비용을 아껴야 하고 한국어 문서가 많다 → 무료·한국어 강한 bge-m3.

품질이 최우선이고 비용은 괜찮다 → 오픈AI.

한 걸음 더 ▸ (지금 몰라도 됨) — bge-m3는 '급등', '가격' 같은 키워드가 겹치면 잘 찾지만, "시장 물가"처럼 넓은 맥락으로 이어지는 건 살짝 약하다. 모델마다 잘하는 결이 다르다는 것만 알아 두면 된다. 깊은 비교는 더 나중 주제다.


개념 3 — 문서 로더: 바깥 파일을 같은 모양으로 읽기

망가지는 장면

내 자료는 PDF, 동료 자료는 웹페이지, 엑셀에서 받은 건 CSV다.

이걸 다 다른 방식으로 읽으면, 읽는 코드를 파일 종류마다 새로 짜야 한다.

뒤에 자르고·숫자로 바꾸는 코드도 종류마다 다 달라진다. 끔찍하다.

일상비유

나라마다 다른 콘센트를 한 모양으로 맞춰 주는 멀티 어댑터.

파일이 PDF든 웹이든 CSV든, 어댑터(문서 로더)를 통과하면 전부 같은 모양(Document)으로 나온다.

비유 코드 위험
웹페이지용 어댑터 WebBaseLoader("https://...") 사이트 막으면 못 읽음
PDF용 어댑터 PyPDFLoader("보고서.pdf") 이미지·빈 페이지는 빠질 수 있음
CSV용 어댑터 CSVLoader("거래.csv") 빈 행도 한 조각으로 셈

한 문장 정의 — 문서 로더는 웹·PDF·CSV 등 80종 넘는 바깥 파일을, 본문(page_content)과 꼬리표(metadata)를 가진 같은 모양의 문서로 바꿔 주는 어댑터다.

단계별 예시

예시 1 (worked — 완성). PDF를 읽으면 본문과 꼬리표가 같이 나온다.

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("KB부동산보고서.pdf")
pages = loader.load()             # 페이지 단위 Document 목록으로 읽는다 (더 잘게 쪼개기는 다음 절 분할기 담당)
# 결과 한 조각:
# Document(metadata={'source': '...보고서.pdf', 'page': 8},
#          page_content='2024년 주택시장 전망...')

예시 2 (worked — 완성). 같은 PDF라도 어댑터 종류에 따라 속도와 꼬리표 양이 다르다.

로더 속도 (책 실측) 꼬리표(메타데이터) 언제 쓰나
PyPDFLoader 보통 (약 24초) 기본(출처·페이지) 간단한 표준 PDF
PyMuPDFLoader 빠름 (약 2.6초) 풍부(작성자·포맷·전체쪽수) 대용량·여러 파일
PDFPlumberLoader 느림 (약 39초) 풍부(표를 HTML로) 표·구조가 중요할 때

세 로더 모두 같은 PDF를 83개 조각으로 읽었다.

다만 빠른 건 2.6초, 느린 건 39초였다.

급할 땐 빠른 로더, 표가 중요하면 느려도 표 잘 뽑는 로더를 고른다.

예시 3 (부분완성 — 빈칸 채우기). 상황에 맞는 로더를 골라 보자.

# 200쪽 PDF 100개를 빨리 읽어야 한다 → ____Loader
# 표가 빼곡한 PDF의 표를 살려야 한다 → ____Loader
# 보기: PyPDF / PyMuPDF / PDFPlumber

정답: 빠른 건 PyMuPDF, 표는 PDFPlumber.

예시 4 (독립적용 — 미니 시나리오). 웹페이지 여러 개를 한 번에 읽기.

읽고 싶은 주소를 리스트로 넘긴다.

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader([
    "https://docs.langchain.com/oss/python/langchain/overview",
    "https://langchain-ai.github.io/langgraph/",
])
# 이 줄에서 실제로 인터넷에 접속해 페이지를 받아 온다 — 연결이 없거나 사이트가 막으면 여기서 실패
docs = loader.load()
docs[0].metadata   # {'source': '...', 'title': '...', 'language': 'en'}

PDF든 웹이든, 결과 모양(Document)이 똑같다.

그래서 다음 단계(자르기·숫자로 바꾸기)를 파일 종류와 상관없이 그대로 쓸 수 있다.


개념 4 — 텍스트 분할: 길이로 자르기 vs 의미로 자르기

망가지는 장면

KB 부동산 보고서는 90,699자다.

이걸 통째로 AI에 넣으면? 너무 길어서 못 받는다.

설령 받아도, "환불 규정"을 물었는데 책 전체가 딸려 와 정작 필요한 한 단락을 못 짚는다.

그래서 미리 조각(청크)으로 잘라 둬야 한다.

일상비유

두꺼운 책을 페이지별로 찢어 분류함에 꽂는 것.

자르는 방법은 두 가지다.

하나는 자(尺)로 길이 재서 똑같이 자르기(빠름, 단순).

하나는 내용을 읽고 주제 바뀌는 데서 자르기(똑똑, 느림·비쌈).

비유 코드 위험
자로 길이 재서 자르기 RecursiveCharacterTextSplitter(chunk_size=500) 문장 중간에서 뚝 끊길 수 있음
자른 조각끼리 살짝 겹치기 chunk_overlap=50 0이면 경계에서 맥락 끊김
주제 바뀌는 데서 자르기 SemanticChunker(OpenAIEmbeddings()) 똑똑하지만 비용 듦

한 문장 정의 — 텍스트 분할은 긴 문서를 검색하기 좋은 조각으로 자르는 일이다. 길이로 자르는 RecursiveCharacterTextSplitter는 빠르고, 의미로 자르는 SemanticChunker는 조각마다 한 주제가 모이게 한다.

단계별 예시

예시 1 (worked — 완성). 길이로 자르기. 500자 넘지 않게, 50자씩 겹쳐서.

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
texts = splitter.split_documents(pages)
len(texts)   # 242  ← 84쪽 PDF가 242조각으로

chunk_size는 한 조각 최대 글자 수.

chunk_overlap은 옆 조각과 겹치는 글자 수.

겹침을 두면 1번 조각 끝 50자가 2번 조각 앞에도 들어가, 경계에서 말이 잘리는 걸 줄인다.

예시 2 (worked — 완성). 길이 자르기의 순서는 큰 단위부터 작은 단위로.

# 기본 구분자 순서: 단락 → 줄 → 공백 → 글자
["\n\n", "\n", " ", ""]
# 먼저 단락(\n\n)으로 자르고, 그래도 길면 줄(\n)로, 또 길면 공백으로...

큰 덩어리부터 자르니 "재귀적(recursive)"이라 부른다.

예시 3 (worked — 완성). 의미로 자르기. 같은 PDF, 방식만 바꾸면 조각 수가 달라진다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# 기본(백분위수 95): 의미 차이 상위 5% 지점에서만 자름
splitter = SemanticChunker(OpenAIEmbeddings())
# 자를 자리를 찾으려고 문장마다 임베딩을 계산한다 — 길이로 자를 때와 달리 여기서 비용이 발생한다
chunks = splitter.split_documents(pages)
len(chunks)   # 165
방식 설정 이름 성격 조각 수(책 실측)
백분위수(기본) "percentile" 의미가 극단적으로 바뀌는 데서만 165
표준편차 "standard_deviation" 평균보다 훨씬 벗어난 데서 (덜 자름) 84
사분위수 "interquartile" 가운데 50% 기준, 튀는 값에 덜 민감 145

세 방식 모두 코사인 거리로 "문장끼리 얼마나 다른가"를 재고, 많이 다른 데서 자른다.

기준값을 낮추면 조각이 많아지고(짧아짐), 높이면 적어진다(길어짐).

예시 4 (부분완성 — 빈칸). 둘 중 뭘 고를까?

# 일단 빨리 돌려 보고 싶다 (비용 0)        → ____TextSplitter
# 검색 정확도가 핵심이고 비용은 괜찮다     → ____Chunker
# 보기: RecursiveCharacter / Semantic

정답: 빠른 건 RecursiveCharacter, 정확한 건 Semantic.

예시 5 (독립적용 — 미니 시나리오). 90,699자 보고서를 1,000자 조각으로.

chunk_size=1000, chunk_overlap=200으로 자른다.

84쪽이 138조각이 된다.

가장 긴 조각 966자, 가장 짧은 56자, 평균 664자.

모든 조각이 1,000자를 넘지 않는 걸 확인할 수 있다.

한 걸음 더 ▸ (지금 몰라도 됨) — 길이로 자르면 문장이 뚝 끊긴 불완전한 조각이 생겨, RAG 챗봇 답 품질이 떨어질 수 있다. 그래서 의미 기반 분할이 나왔다. 단, 의미 분할은 문장마다 임베딩 API를 부르니 돈이 더 든다. 어느 쪽이 나은지는 문서와 상황에 따라 다르다. 지금은 "두 가지 방법이 있다"만 알면 충분하다.


개념 5 — 벡터 DB: 숫자 조각 창고와 빠른 꺼내기

망가지는 장면

앞에서는 판다스(엑셀 비슷한 표)에 숫자 조각을 담고, 직접 코사인 유사도를 다 계산했다.

조각이 6개일 땐 괜찮다.

그런데 100만 개면? 질문 하나에 100만 번 계산해야 한다. 너무 느리다.

일상비유

신상카드를 잔뜩 모았으면, 그냥 상자에 쏟아 두지 말고 잘 정리된 캐비닛에 넣어 둔다.

"나랑 비슷한 사람" 카드를 순식간에 뽑아 주는 캐비닛이 벡터 DB다.

대표 캐비닛이 둘. 가벼운 Chroma, 빠른 FAISS.

비유 코드 위험
가벼운 캐비닛(프로토타입) Chroma.from_documents(splits, embedding) 큰 규모엔 한계
빠른 대형 캐비닛(대규모) FAISS.from_documents(splits, embedding) 설정·분산은 까다로움
캐비닛에서 비슷한 카드 꺼내기 store.similarity_search("질문", k=3) k 안 정하면 기본 4개

한 문장 정의 — 벡터 DB는 숫자 조각(임베딩)을 잔뜩 저장하고 "비슷한 것 찾아줘"를 빠르게 해 주는 창고다. 가벼운 Chroma와 빠른 FAISS가 대표이며, 둘 다 as_retriever()로 검색기로 바꿔 쓴다.

단계별 예시

예시 1 (worked — 완성). Chroma에 넣고, 비슷한 조각 꺼내기.

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# 138개 조각을 숫자로 바꿔 창고에 저장
vectordb = Chroma.from_documents(
    documents=splits,
    embedding=OpenAIEmbeddings(),
    persist_directory="./chroma_db",   # 이 경로에 파일로 영구 저장
)

# "수도권 주택 매매 전망"과 비슷한 조각 3개 꺼내기
docs = vectordb.similarity_search("수도권 주택 매매 전망", k=3)

persist_directory를 적으면 디스크(chroma.sqlite3)에 남아, 다음에 또 쓴다.

안 적으면 메모리에만 있다가 끄면 사라진다.

예시 2 (worked — 완성). FAISS는 거의 같지만 저장·확인 방법이 다르다.

from langchain_community.vectorstores import FAISS

faiss_db = FAISS.from_documents(documents=splits, embedding=OpenAIEmbeddings())
faiss_db.index.ntotal      # 138  ← Chroma는 _collection.count()

faiss_db.save_local("./faiss_index")   # Chroma는 persist_directory로 자동

같은 일(저장·확인)을 하는데 메서드 이름만 다르다.

Chroma는 persist_directory·_collection.count(), FAISS는 save_local()·index.ntotal.

예시 3 (부분완성 — 빈칸). 캐비닛을 골라 보자.

# 혼자 빠르게 실험·프로토타입                    → ______
# 수백만 벡터를 GPU로 초고속 검색해야 함          → ______
# 보기: Chroma / FAISS

정답: 가벼운 실험은 Chroma, 대규모 초고속은 FAISS(메타 개발, GPU 가속).

예시 4 (독립적용 — 미니 시나리오). 창고를 검색기로 바꾸기.

창고는 그냥 두면 "저장소"다.

as_retriever()를 부르면 질문 받아 꺼내 주는 "검색기"가 된다.

# 꺼낼 개수(k) 같은 세부 설정은 search_kwargs 꾸러미에 담아서 넘긴다
retriever = vectordb.as_retriever(search_kwargs={"k": 3})
# 이제 retriever.invoke("질문") 하면 비슷한 조각 3개를 돌려줌

중요한 점 하나.

as_retriever()는 Chroma든 FAISS든 똑같이 쓴다.

한번 검색기로 만들면, 창고 종류와 상관없이 다음 단계(챗봇)에 꽂을 수 있다.

참고 — FAISS를 파일에서 다시 불러올 땐 allow_dangerous_deserialization=True를 켠다. 파이썬이 "이 파일 위험할 수 있다"고 막는 걸 푸는 옵션이다. 내가 방금 저장한 파일일 때만 켠다.


개념 6 — RAG 챗봇과 스트림릿: 부품을 한 줄로 잇고 화면 입히기

망가지는 장면

부품은 다 만들었다. 로더, 분할기, 임베딩, 창고, 검색기.

그런데 따로 놀면 챗봇이 아니다.

질문이 오면 → 검색기로 조각 찾고 → 조각을 프롬프트에 넣고 → AI에게 보내 답받는 한 줄로 이어야 한다.

게다가 까만 터미널 화면은 친구에게 보여 주기 민망하다.

일상비유

컨베이어 벨트(체인). 질문을 올리면 검색→프롬프트→모델→정리를 차례로 거쳐 답이 나온다.

그리고 그 위에 예쁜 채팅창(스트림릿)을 씌운다. 손님은 벨트 속을 몰라도 채팅만 하면 된다.

비유 코드 위험
부품을 벨트로 잇기 chain = 검색 \| prompt \| model \| parser 부품 모양 안 맞으면 안 이어짐
이전 대화 기억하기 RunnableWithMessageHistory(...) 없으면 방금 말도 까먹음
채팅 화면 입히기 st.chat_input("질문하세요") 없으면 까만 터미널뿐
무거운 준비는 한 번만 @st.cache_resource 없으면 질문마다 PDF 재로드

한 문장 정의 — RAG 챗봇은 검색기·프롬프트·모델을 한 줄(체인)로 잇고 대화 기억을 붙인 것이며, 스트림릿은 그 위에 파이썬만으로 채팅 화면을 입히는 도구다.

단계별 예시

예시 1 (worked — 완성). 부품을 한 줄로 잇기. 0장 척추 6번 "RAG는 한 줄"의 실제 코드다.

def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)   # 조각들을 한 덩어리로

# assign: 들어온 질문은 그대로 흘려보내면서, 검색해 온 조각을 context라는 이름으로 옆에 붙인다
# 프롬프트가 질문과 조각 둘 다 필요해서, 하나로 묶어 넘기는 것이다
chain = (
    RunnablePassthrough.assign(
        context=lambda x: format_docs(retriever.invoke(x["question"]))  # ① 검색
    )
    | prompt    # ② 질문+조각을 지시문에 끼움
    | model     # ③ AI에게 보냄
    | StrOutputParser()   # ④ 답을 깔끔한 글자로
)

|(파이프) 하나하나가 "그다음으로 넘긴다"는 뜻이다.

검색 → 프롬프트 → 모델 → 정리. 0장에서 본 그 한 줄 그대로다.

예시 2 (worked — 완성). 대화 기억 붙이기. 이게 있어야 "방금 뭐라 했지?"에 답한다. (RunnableWithMessageHistory는 아직 동작하지만 은퇴 예고 상태 — 현행 공식 권장은 6장의 LangGraph 체크포인터다. 여기서는 흐름 이해용으로 쓴다.)

chat_history = ChatMessageHistory()
chain_with_memory = RunnableWithMessageHistory(
    chain,
    # 세션 번호를 받아 그 사람의 대화 기억을 돌려주는 규칙 — 연습용이라 누가 와도 기억 하나를 같이 쓴다
    lambda session_id: chat_history,
    # 아래 두 줄은 칸 이름 지정: 질문이 입력의 어느 칸에 있고, 지난 대화를 어느 칸에 끼울지
    # 프롬프트에 만들어 둔 칸 이름과 똑같아야 기억이 제대로 이어진다
    input_messages_key="question",
    history_messages_key="chat_history",
)

이 기억 덕분에, 사용자가 "방금 제가 뭐라고 했죠?" 물으면 챗봇이 직전 말을 그대로 짚어 준다.

예시 3 (worked — 완성). 화면 입히기. 채팅창 한 줄이면 된다.

import streamlit as st

st.title("KB 부동산 보고서 챗봇")
# :=는 입력을 받으면서 동시에 검사한다 — 사용자가 아직 아무것도 안 쳤으면 아래를 통째로 건너뛴다
if user_input := st.chat_input("부동산 질문을 입력하세요"):
    # 답을 기다리는 동안 '답변 생성 중...' 돌림 표시를 띄우고, 이 묶음이 끝나면 알아서 지운다
    with st.spinner("답변 생성 중..."):
        response = chain.invoke({"question": user_input}, ...)
        st.markdown(response)

HTML·CSS는 한 줄도 없다.

파이썬만으로 채팅창·로딩 표시·답 출력이 다 된다.

예시 4 (부분완성 — 빈칸). 스트림릿 챗봇이 질문할 때마다 PDF를 다시 읽어 너무 느리다. 무엇을 빠뜨렸나?

# 무거운 준비(PDF 로드·창고 만들기) 함수 위에 빠진 한 줄:
# @ 표식(데코레이터)은 바로 아래 함수에 능력을 하나 덧입히는 문법 — 여기선 '한 번 만든 결과를 기억해 재사용'이 필요하다
@____________
def build_chain():
    ...
# 보기: st.cache_resource

정답: @st.cache_resource. 이게 있어야 무거운 준비를 딱 한 번만 하고 결과를 재사용한다.

예시 5 (독립적용 — 미니 시나리오). 완성된 챗봇과의 실제 대화.

사용자: "랭체인 챗봇 만들기 실습 중입니다."

사용자: "방금 제가 뭐라고 했죠?" → 챗봇이 직전 말을 정확히 짚어 준다(기억 덕분).

사용자: "노후계획도시가 어디야?" → 보고서 조각을 찾아 "분당·일산·중동·평촌·산본"이라 답한다(검색 덕분).

기억 + 검색이 합쳐져, 우리 PDF 기반의 똑똑한 챗봇이 완성됐다.


전체 한 줄 다시 보기

이 장에서 만든 부품을 순서대로 이으면 RAG 한 줄이다.

[인덱싱 — 미리 준비]
문서 → 문서 로더 → 텍스트 분할 → 임베딩 → 벡터 DB 저장

[쿼리 — 질문 올 때마다]
질문 → 검색기(비슷한 조각 k개) → 프롬프트 → 모델 → 답

인덱싱은 도서관에 책을 미리 분류해 꽂아 두는 일.

쿼리는 손님이 물을 때 찾아다 주는 일.

0장 척추 6번에서 그림으로 봤던 그 한 줄을, 이번엔 직접 손으로 만들었다.


단순 규칙 (헷갈리면 이대로)

  • 임베딩 모델: 일단 오픈AI(고품질)로 시작, 비용·한국어 중요하면 bge-m3(무료).
  • 로더: 보통 PyPDFLoader, 빠른 게 필요하면 PyMuPDFLoader, 표가 중요하면 PDFPlumberLoader.
  • 분할: chunk_size=1000, chunk_overlap=200으로 시작, 정확도 보며 조정.
  • 창고: 실험은 Chroma, 수백만 규모는 FAISS.
  • 검색 개수: k=3 명시(안 하면 기본 4개라 토큰 낭비).
  • 스트림릿: 무거운 준비 함수엔 @st.cache_resource 꼭.

더 해보기

검증된 공식 실습·문서 (생존 확인 2026-05-21).

최신 동향 (검증 2026-07-09) — 책은 2025년(랭체인 0.3) 기준이라, 그 뒤 바뀐 것만 짧게. ① 책의 text-embedding-ada-002 대신 text-embedding-3-small/-large가 현행 오픈AI 임베딩이다(차원 축소 옵션으로 저장 비용 절감 가능) — 공식 목록. ② 2025-10 랭체인 1.0이 나오면서 옛 부품 상당수가 langchain-classic 패키지로 분리됐고, 허깅페이스 연동은 langchain-huggingface 전용 패키지가 표준이 됐다 — 마이그레이션 가이드. 본문 코드는 전부 현행 기준으로 갱신돼 있다.



연습문제

  1. 설명. RAG 한 줄 직접 만들기의 핵심을 처음 듣는 사람에게 한 문장으로 설명하라.
  2. 구분. 두 개념(검색, 생성)을 실제 예시 하나로 구분하라.
  3. 적용. 내 프로젝트나 학습 노트에서 이 장의 개념을 적용해 작게 개선할 지점을 하나 고르라.

부록 A. 쉬운 용어 사전

한글 용어 원문 영문명 아주 쉬운 뜻 이 장에서 나온 위치
검색 - 질문에 답하기 전에 관련 자료나 근거를 찾아오는 일. 부록 B와 본문 예시
생성 - 찾은 근거와 요청을 바탕으로 새 답변 문장을 만드는 일. 부록 B와 본문 예시
문서 로더 DocumentLoader PDF, 웹, 파일 같은 자료를 프로그램이 읽을 수 있는 문서 형태로 바꾸는 부품. 부록 B와 본문 예시
텍스트 분할 - 긴 문서를 검색하기 좋게 작은 조각으로 나누는 작업. 부록 B와 본문 예시
## 부록 B. 헷갈리는 개념 비교표
A B 구분 포인트
검색 생성 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다.
문서 로더 텍스트 분할 로더는 읽어 오고, 분할은 읽어 온 것을 작은 조각으로 나눈다.

부록 C. 연습문제 풀이

  1. 설명 예시. RAG 한 줄 직접 만들기는 RAG에서 자료를 더 잘 찾고, 근거를 더 안전하게 붙이고, 답변 흐름을 더 다루기 쉽게 만드는 방법을 보는 장이다. 중요한 것은 용어를 외우는 것이 아니라, 이 개념이 어떤 입력·부품·결정에 영향을 주는지 말로 풀어 보는 것이다.
  2. 구분 예시. 두 개념(검색, 생성)의 차이는 이렇게 잡으면 된다. 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다. 실제 사례를 볼 때는 목적, 입력, 실패했을 때의 증상을 따로 적어 보면 헷갈리지 않는다.
  3. 적용 예시. 가장 작은 개선부터 고른다. 예를 들어 이름을 더 분명히 하거나, 평가 기준을 한 줄 추가하거나, 직접 알 필요 없는 내부 정보를 감추는 식으로 시작한다. 한 번에 크게 갈아엎는 것보다 작은 변경 하나를 확인하며 진행하는 쪽이 입문 단계에 맞다.

다음 장 예고

다음 장에서는 글뿐 아니라 이미지·표까지 함께 다루는 RAG로 넓혀 간다.

이 장의 한 줄(로드→분할→임베딩→저장→검색→생성)이 머리에 있으면 충분하다. 나머지는 다음 장에서 천천히 얹는다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플   P 음성 버전 2026-08-05b